Skip to content

[Node] Undici WebSocket & Diagnostics #621

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 9, 2025
Merged

[Node] Undici WebSocket & Diagnostics #621

merged 12 commits into from
Jun 9, 2025

Conversation

rkistner
Copy link
Contributor

@rkistner rkistner commented Jun 5, 2025

Undici WebSocket and Dispatcher / proxy support

This switches the NodeJS WebSocket implementation from the ws package to undici WebSocket.

The primary benefit here is that it supports the same Dispatcher interface as for fetch, giving us consistent proxy support. The proxy-agent lib used previously had a couple of limitations:

  1. It requires a separate option to override the agent, making our websockets support inconsistent with fetch/http support.
  2. It does not support setting TLS options for the request - only for the proxy. Since a common requirement for proxies is a proxy intercepting requests, not being able to to specify a custom CA is a big limitation.

The undici WebSocket supports the same dispatcher option as fetch, making the proxy support consistent. I added an example of using this in the example-node demo.

One major limitation with undici WebSocket is that connection errors show up as just Received network error or non-101 status code., regardless of the cause (connection failure, TLS negotiation failure, proxy failure, etc).

The workaround here is to register a custom wrapping Dispatcher that does have access to the underlying error, and emit that on the WebSocket.

Connection diagnostics

Undici has diagnostics_channel support. This gives tracing of network connections, requests and responses. I added an example of using this in example-node.

This does not log individual messages sent/received over the connection, so this only helps to debug connection-related issues. We can add logging of the websocket messages separately.

Example output with a TLS failure
[PowerSyncDemo] Attempting to connect to PowerSync instance
[PowerSyncStream] Streaming sync iteration started
[PowerSyncStream] Requesting stream from server
🔄 [DIAG-1] REQUEST CREATE: {
  host: 'https://65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
  path: '/sync/stream',
  method: 'GET',
  headers: [
    'User-Agent',
    'powersync-js/1.31.1 powersync-node node/24.1.0 linux/6.15.0-061500-generic',
    'sec-websocket-key',
    '0SIYYsYhiXPVKpYdREFQBw==',
    'sec-websocket-version',
    '13',
    'sec-websocket-extensions',
    'permessage-deflate; client_max_window_bits',
    'accept',
    '*/*',
    'accept-language',
    '*',
    'sec-fetch-mode',
    'websocket',
    'pragma',
    'no-cache',
    'cache-control',
    'no-cache',
    'accept-encoding',
    'br, gzip, deflate'
  ],
  contentType: null,
  contentLength: null
}
🔌 [DIAG] CLIENT BEFORE CONNECT: {
  connectParams: {
    host: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    hostname: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    protocol: 'https:',
    port: '',
    version: undefined,
    servername: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    localAddress: null
  }
}
🔄 [DIAG-2] REQUEST CREATE: {
  host: 'http://localhost:8080',
  path: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com:443',
  method: 'CONNECT',
  headers: [],
  contentType: null,
  contentLength: null
}
🔌 [DIAG] CLIENT BEFORE CONNECT: {
  connectParams: {
    host: 'localhost:8080',
    hostname: 'localhost',
    protocol: 'http:',
    port: '8080',
    version: undefined,
    servername: null,
    localAddress: null
  }
}
✅ [DIAG] CLIENT CONNECTED: {
  connectParams: {
    host: 'localhost:8080',
    hostname: 'localhost',
    protocol: 'http:',
    port: '8080',
    version: 'h1',
    servername: null,
    localAddress: null
  },
  connector: 'connect',
  socket: {
    localAddress: '127.0.0.1',
    localPort: 53844,
    remoteAddress: '127.0.0.1',
    remotePort: 8080
  }
}
📡 [DIAG] CLIENT SEND HEADERS: {
  headers: 'CONNECT 65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com:443 HTTP/1.1\r\n' +
    'host: 65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com\r\n' +
    'connection: close\r\n'
}
📤 [DIAG-2] REQUEST BODY SENT
❌ [DIAG] CLIENT CONNECT ERROR: {
  connectParams: {
    host: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    hostname: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    protocol: 'https:',
    port: '',
    version: undefined,
    servername: '65082d7c81b6ab41b7fa7bd5.powersync.staging.journeyapps.com',
    localAddress: null
  },
  error: Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
      at TLSSocket.onConnectSecure (node:_tls_wrap:1631:34)
      at TLSSocket.emit (node:events:507:28)
      at TLSSocket.emit (node:domain:489:12)
      at TLSSocket._finishInit (node:_tls_wrap:1077:8)
      at ssl.onhandshakedone (node:_tls_wrap:863:12) {
    code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
  }
}
❌ [DIAG-1] REQUEST ERROR: {
  error: Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
      at TLSSocket.onConnectSecure (node:_tls_wrap:1631:34)
      at TLSSocket.emit (node:events:507:28)
      at TLSSocket.emit (node:domain:489:12)
      at TLSSocket._finishInit (node:_tls_wrap:1077:8)
      at ssl.onhandshakedone (node:_tls_wrap:863:12) {
    code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
  }
}
[PowerSyncDemo] Socket error Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
    at TLSSocket.onConnectSecure (node:_tls_wrap:1631:34)
    at TLSSocket.emit (node:events:507:28)
    at TLSSocket.emit (node:domain:489:12)
    at TLSSocket._finishInit (node:_tls_wrap:1077:8)
    at ssl.onhandshakedone (node:_tls_wrap:863:12) {
  code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
}
[PowerSyncDemo] Socket error Error: Received network error or non-101 status code.
    at #onFail (/home/ralf/src/powersync-js/packages/node/node_modules/undici/lib/web/websocket/websocket.js:469:16)
    at Object.onFail (/home/ralf/src/powersync-js/packages/node/node_modules/undici/lib/web/websocket/websocket.js:63:43)
    at failWebsocketConnection (/home/ralf/src/powersync-js/packages/node/node_modules/undici/lib/web/websocket/connection.js:318:11)
    at Object.processResponse (/home/ralf/src/powersync-js/packages/node/node_modules/undici/lib/web/websocket/connection.js:108:9)
    at /home/ralf/src/powersync-js/packages/node/node_modules/undici/lib/web/fetch/index.js:1072:19
    at node:internal/process/task_queues:151:7
    at AsyncResource.runInAsyncScope (node:async_hooks:214:14)
    at AsyncResource.runMicrotask (node:internal/process/task_queues:148:8)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
[PowerSyncStream] Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
    at TLSSocket.onConnectSecure (node:_tls_wrap:1631:34)
    at TLSSocket.emit (node:events:507:28)
    at TLSSocket.emit (node:domain:489:12)
    at TLSSocket._finishInit (node:_tls_wrap:1077:8)
    at ssl.onhandshakedone (node:_tls_wrap:863:12) {
  code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
}
[PowerSyncStream] Initial connect attempt did not successfully connect to server

Error handling

This now passes websocket connection errors through as-is, instead of wrapping them. Wrapping the errors often caused info to get lost, especially in the JSON.stringify() part.

This now also improves websocket errors for react-native, by using event.message (from the example here).

Copy link

changeset-bot bot commented Jun 5, 2025

🦋 Changeset detected

Latest commit: af647cd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@powersync/node Minor
@powersync/common Minor
@powersync/op-sqlite Patch
@powersync/react-native Patch
@powersync/tanstack-react-query Patch
@powersync/web Patch
@powersync/diagnostics-app Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rkistner rkistner marked this pull request as ready for review June 9, 2025 09:19
@rkistner rkistner requested a review from Chriztiaan June 9, 2025 09:24
@rkistner rkistner merged commit efc8ba9 into main Jun 9, 2025
10 of 11 checks passed
@rkistner rkistner deleted the undici-websockets branch June 9, 2025 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants